Skip to content

feat(gateway,ports,http,cli): gateway skeleton — config.mode, uplink, worker views, worker.* (ADR 0005, #117) - #129

Merged
V3RON merged 30 commits into
claude/adr-0005-116-device-execfrom
claude/adr-0005-117-gateway-skeleton
Sep 9, 2026
Merged

feat(gateway,ports,http,cli): gateway skeleton — config.mode, uplink, worker views, worker.* (ADR 0005, #117)#129
V3RON merged 30 commits into
claude/adr-0005-116-device-execfrom
claude/adr-0005-117-gateway-skeleton

Conversation

@V3RON

@V3RON V3RON commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Gateway skeleton for ADR 0005 (requirements 1–9, 20–25, 31–33): a daemon can
now run as a gateway that owns no devices and fronts the workers connected
to it over a persistent uplink.

This PR is stacked on claude/adr-0005-116-device-exec (#128, device.exec)
and rebased onto its final head (4e6fe22) so it inherits #116's decisions
rather than re-asserting its own earlier ones: the protocol bump to 5, mode
living in status.get's daemon block, and #116's exec authorization/
backpressure work.

What changed, by layer

  • Contract (src/contract/): config.mode: "worker" | "gateway";
    gateway.{url,token,label,disconnectedRetentionMs,execTimeoutMs} config
    keys; worker token role (403 on a /v1 route); workerViewSchema;
    status.get gains workers[] and a daemon: { health, mode } block (mode
    and health share one block, not two top-level fields — see "Decisions"
    below); workerId on every device and lease; worker.list|drain|undrain| remove operations; UNSUPPORTED_IN_GATEWAY_MODE and WORKER_CONNECTED
    error codes.
  • Core (src/core/config.ts): gateway config validation — a gateway
    requires http.enabled: true (fails closed, naming the key), worker-only
    keys in a gateway's config are warned and ignored, gateway.url is
    validated as ws:///wss://, mode resolves from file/override layers
    before anything else is defaulted.
  • src/gateway/ (new module): WorkerRegistry (worker views, connect/
    disconnect/incompatible/drain state, disconnectedRetentionMs eviction —
    never while a view holds leases), GatewayService (uplink accept, hello
    negotiation, admin session grant, status.get/list.get/catalog.get/
    events.subscribe refresh on connect + capacity/lease events + a slow
    tick), GatewayDispatcher (the contract's second implementation —
    nuke.run, cleanup.run, doctor.run, driver.passthrough, and
    device.exec all answer UNSUPPORTED_IN_GATEWAY_MODE until Gateway fleet queue, routing, lease forwarding, fleet-wide one-lease rule #118's
    routing lands), aggregateStatus/aggregateCatalog (pure functions over
    worker views), a DrainStore for persisted drain state, and
    boundary.test.ts enforcing ADR §33: nothing from drivers, http,
    cli, mcp; from daemon only the core-free dispatch.js; nothing from
    core at all in this PR (the fleet queue arrives with Gateway fleet queue, routing, lease forwarding, fleet-wide one-lease rule #118).
  • Uplink ports (src/ports/uplink.ts, uplink-websocket.ts):
    UplinkListenerFactory (gateway side) and UplinkConnector (worker side)
    with a real WebSocket adapter and an in-memory fake for tests. The worker
    dials on start and reconnects with exponential backoff; the gateway grants
    the uplink session admin.
  • Daemon (src/daemon/): dispatch.ts split out of dispatcher.ts
    DispatchSession/DispatchError/ContractDispatcher plus a shared
    DispatchPipeline/runDispatch — so src/gateway/ can depend on the
    transport-facing dispatch contract without pulling in src/core through
    dispatcher.ts. DaemonServer now serves either a worker's Dispatcher
    or a GatewayDispatcher depending on config.mode; a gateway starts no
    drivers, roots, reaper, health monitor, or capacity strategy, and always
    listens on HTTP and its unix socket. gateway-uplink.ts wires the worker
    side's dial/reconnect loop into main.ts.
  • HTTP/CLI: GET /v1/workers, POST/DELETE /v1/workers/{id}/drain,
    DELETE /v1/workers/{id}; simlock worker list|drain|undrain|remove;
    simlock daemon start starts whichever mode is configured; simlock status renders the fleet block (mode, each worker, which worker a device/
    lease lives on) when talking to a gateway.
  • Events: worker.connected|disconnected|removed|drain-started| drain-ended (gateway-only, documented in EVENTS.md); a worker's own
    events are republished on the gateway's bus with workerId added, under
    their original names; device.exec still emits nothing (unchanged from
    device.exec: run simctl/adb on the worker and stream output over the contract #116).
  • Docs: ARCHITECTURE.md (topology diagram, protocol range rationale),
    CLI.md (exit codes, simlock worker, the device.exec gateway-mode
    note), CONFIGURATION.md (mode, gateway.*), EVENTS.md (fleet
    events section), HTTP-API.md (/v1/workers), CHANGELOG.md.

Decisions worth flagging

Rebase note (for reviewers of the diff)

The rebase carried real conflicts in src/core/config.ts, src/daemon/ dispatcher.ts/dispatch.ts, src/contract/operations.ts/protocol.ts,
src/cli/index.ts, and the docs — both branches touched status.get,
mode, config keys, and the protocol version. Beyond the marked conflicts,
a handful of files needed silent-merge fixups the diff will show as small,
separate changes on top of the original commits: a few testConfig
fixtures had ended up with a duplicated mode: "worker" key (harmless
duplicate object keys, but tsc correctly rejects them), and src/gateway/ aggregate.ts, worker-link.ts, test-support.ts, and two .test.ts files
still referenced the pre-#116 top-level mode/health shape — expected,
since the branch's own last commit (3d53b21, "mode is a field of
status.get's daemon block") says outright that "the code follows on the
rebase onto that move." That follow-up is folded into that same commit here
rather than left as a separate "fix the rebase" commit.

Test inventory

  • src/gateway/worker-registry.test.ts, service.test.ts,
    dispatcher.test.ts, aggregate.test.ts, boundary.test.ts — the new
    module's own unit coverage (connect/disconnect/incompatible/drain
    transitions, retention timing, hello negotiation, every gateway-only
    operation's refusal, aggregation math, the import boundary).
  • src/ports/uplink.test.ts — the in-memory uplink fake against both port
    interfaces.
  • src/daemon/gateway-uplink.test.ts — the worker-side dial/reconnect loop.
  • src/core/config.test.ts — gateway config validation (HTTP-required,
    worker-only-key warnings, gateway.url scheme validation, mode
    resolution order).
  • src/contract/operations.test.tsstatus.get/catalog.get/worker.*
    schema round trips, including the gateway shape from requirement above.
  • src/http/app.test.ts, src/cli/index.test.ts/v1/workers routes and
    simlock worker/simlock status rendering.
  • e2e/gateway-fleet.test.ts (new) — two fake-driver workers join a
    gateway; simlock status against the gateway shows both with capacity,
    leases, and catalog; killing one flips it to disconnected without
    polling; drain/undrain round-trips. This is the issue's "Done when"
    scenario.

Validation run on the final head (7470820)

  • pnpm run typecheck — clean
  • pnpm run typecheck:e2e — clean
  • pnpm run lint — clean
  • pnpm run format:check — clean
  • pnpm run test (unit) — 90 files, 1622 passed, 2 skipped
  • pnpm run test:e2e (fast tag) — 17 files, 56 passed, 1 expected fail, 9
    skipped
  • pnpm exec fallow audit --base origin/claude/adr-0005-116-device-exec
    clean (72 changed files)

Deviations from the ADR

None found in this PR's scope (requirements 1–9, 20–25, 31–33). Requirements
outside that range (fleet queue/routing, failure paths, durable retry) are
explicitly out of scope here and land with #118/#119.

Part of #115
Closes #117

@V3RON
V3RON force-pushed the claude/adr-0005-117-gateway-skeleton branch 2 times, most recently from ad3fd2f to 9c5f97a Compare September 7, 2026 16:54
@V3RON
V3RON force-pushed the claude/adr-0005-117-gateway-skeleton branch 2 times, most recently from f9eb9cf to f3d322b Compare September 8, 2026 17:48
@V3RON
V3RON added this pull request to stack #123 September 9, 2026 16:16
…codes

The contract half of ADR 0005 #117, plus the config keys the two modes read.

Contract:
- `status.get` gains `daemon.mode` (every daemon says which mode answered) and
  an additive, gateway-only `workers` array of worker views; its devices and
  leases gain an optional `workerId`, so a fleet's aggregate is the same shape
  a single host returns (§20). `leaseRecordSchema` itself is untouched --
  `status.get` uses an extension of it, so `lease.renew`/`lease.list` keep
  their shapes.
- `catalog.get`'s per-platform entry gains `modelWorkers`/`runtimeWorkers`
  annotations (§21), beside the flat lists rather than instead of them, so an
  existing renderer is unaffected.
- `worker.list|drain|undrain|remove` (admin, §8/§23) and the worker view shape
  they return. A worker's own dispatcher deliberately declares no handler for
  these -- its handler map now excludes `GATEWAY_ONLY_OPERATIONS` -- so asking
  a worker for one answers `UNKNOWN_REQUEST` rather than a fabricated empty
  fleet.
- Token role `worker` (§8/§25): a join token. The HTTP bearer adapter answers
  `403` for one on any `/v1` route -- the one role decision that belongs at
  the transport, because a join token authorizes a transport and no operation
  at all.
- Error codes `UNSUPPORTED_IN_GATEWAY_MODE` (501), `WORKER_CONNECTED` (409),
  and `UNKNOWN_WORKER` (404). The third is not in the ADR's list; drain,
  undrain and remove all need an answer for an id the gateway has never seen,
  and inventing a silent success for it would be worse than a fourth row.

Config:
- `mode: "worker" | "gateway"`, default `worker` (§1).
- `gateway.url`/`gateway.token`/`gateway.label` (worker side, §3) and
  `gateway.disconnectedRetentionMs` (gateway side, default 24h, §6).
- A gateway defaults `http.enabled` to true and refuses an explicit `false`
  naming the key (§2): HTTP is how agents reach the fleet *and* what the
  worker uplink upgrades from, so a gateway without it is unreachable. A
  worker's HTTP gateway stays opt-in exactly as before.
- Worker-only keys in a gateway's config warn and are ignored, per key the
  operator actually wrote (§2); `gateway.url`/`token`/`label` warn there too.
- `gateway.url` is validated as a ws:// or wss:// URL at load, so a typo fails
  the start naming the key instead of surfacing as an endless reconnect loop.

`DispatchSession`/`DispatchError`, and the new `ContractDispatcher` shape both
dispatchers satisfy, move to `src/daemon/dispatch.ts` -- a leaf module with no
`src/core` imports, so the gateway can implement the contract without
importing the worker's engine through the back door. `dispatcher.ts`
re-exports them, so no call site changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…ther dispatcher

ADR 0005 §4/§5/§33. Two halves of one link, behind a port:

- `UplinkConnector` (worker side) dials `gateway.url` with the join token and
  the worker's instance id; `UplinkListenerFactory` (gateway side) accepts an
  authenticated uplink. Both traffic in `IpcConnection`, because what travels
  over the link is the existing daemon protocol -- the worker's own
  `DaemonServer` serves it, and the gateway drives it with the typed client.
- `MemoryUplinkTransport` is the in-process adapter: it runs the same
  authentication and rejects with the same codes, so a test that exercises a
  revoked token exercises the real branch.
- The WebSocket adapters live in `ports/uplink-websocket.ts`, the one module
  that imports `ws` -- kept out of the ports barrel so the CLI and the MCP
  server never load it. `ws` (pinned 8.18.3) is the one new runtime
  dependency: Node 22 has a WebSocket client and no server, and its client
  cannot set the request headers the join token and worker id travel in. The
  gateway's half is `noServer: true` and upgrades on the *existing* HTTP
  listener, so the fleet still has exactly one inbound port; authentication
  happens at upgrade, so an unrecognized peer gets a plain 401 and never
  reaches the daemon protocol.

`GatewayUplink` is the worker's reconnect supervisor: dial on start, redial
after any disconnect with exponential backoff, capped and jittered across the
lower half of the window (a gateway restart drops every worker at once). A
revoked token keeps retrying at the cap -- an operator may mint a new one at
any moment, and a worker that gave up would need a restart nobody would think
to perform -- but logs `rejected` rather than `unreachable`, so the log says
which it is.

`DaemonServer` gains two things:

- `acceptUplink(connection)`: accepts a worker's outbound uplink as one more
  connection and grants that session `admin`. Not a hole in ADR 0003 §5 -- §5
  forbids inferring authority from a transport the daemon *accepted*; here the
  daemon dialled out, to the URL in its own config, with the token from that
  same file, which the gateway verified before the connection existed. The
  trust runs from the worker's configuration, which is what §5 asks for. Role
  is per-connection state decided by how it was accepted; everything else --
  range negotiation, role checks, ownership -- is unchanged.
- An injected `dispatcher`, as a union with the worker engine options, so a
  gateway can serve the same transport with its own handlers (ADR 0005 §32)
  while a worker's options stay exhaustively checked at compile time. Owner-
  routed lease facts are inert in gateway mode: a worker's republished
  `lease.expired` names that worker's owner, not a gateway client's principal,
  so routing one would push another machine's fact at the wrong holder. #118
  and #119 relay those with the gateway's own lease index.

ADR 0003 §2's dispatch pipeline -- parse input, role check, authorize, park on
readiness, call handler, parse output -- moves into `runDispatch` in
`daemon/dispatch.ts` and is shared by both implementations. A gateway that
checked roles slightly differently from a worker would be a second contract
wearing the first one's name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
`src/gateway/` -- the second implementation of the daemon contract (ADR 0005
§32), for a daemon that owns no devices and fronts the workers connected to it.

- `WorkerLink` drives one uplink as the protocol *client*, with the same typed
  admin client a supervisor uses over a unix socket: `status.get`, `list.get`,
  `catalog.get` and `events.subscribe` on connect (§7), plus `config.get` for
  the worker's download policy, which #118's routing needs as an input (§13).
  It refreshes on every worker event about a lease or a device, coalescing a
  burst into one round trip plus one follow-up, and republishes every worker
  event on the gateway's own bus with `workerId` added (§22) -- name and
  emitting module unchanged, because the fact happened in that worker's lease
  engine and rewriting either would make the audit trail lie.
- A worker whose `hello` finds no overlapping protocol range is `incompatible`
  in its view, carrying both ranges, and is asked nothing further (§31). The
  device records a worker reports are narrowed to the contract's status shape
  on the way in, so no driver-private `driverData` crosses the fleet.
- `WorkerRegistry` owns the views and the facts about them: `worker.connected`,
  `worker.rejected` (incompatible, or an uplink turned away at the upgrade),
  `worker.disconnected`, `worker.removed`, `worker.drain-started`,
  `worker.drain-ended`. Retention keeps a disconnected view until its last
  known lease deadline has passed *and* `gateway.disconnectedRetentionMs` has
  elapsed -- a machine that vanished holding a live lease is exactly what an
  operator must still see. Drain is the one piece of persisted gateway state
  (Decision 3): a tiny owner-only `workers.json`, so a machine taken out of
  service stays out across its own reconnect and a gateway restart.
- `GatewayDispatcher` answers the contract from those views through the shared
  `runDispatch` pipeline. `status.get` and `catalog.get` aggregate (§20/§21);
  `lease.list` and `list.get` report the fleet read-only, each row naming its
  worker -- the fleet made visible, which is this PR's point, and what the
  operator HTTP routes render. `nuke.run`, `cleanup.run`, `doctor.run` and
  `driver.passthrough` answer `UNSUPPORTED_IN_GATEWAY_MODE` permanently (§34);
  the lease *lifecycle* answers it until #118. The handler table is typed total
  over the contract, so a new operation is a compile error here until someone
  decides which of the three populations it belongs to.
- `GatewayService` is the lifecycle: the uplink listener, one link per worker,
  and the slow tick that backstops event-driven refreshes and sweeps retired
  views.

`boundary.test.ts` enforces ADR 0005 §33: nothing here imports `src/core`,
`src/drivers`, or a frontend, and the single `src/daemon` import is the
dispatch contract -- which the same test asserts is itself core-free, so the
allowance cannot become a back door.

Also settled with the ADR review (#127) and applied here: protocol range moves
to `{min: 5, max: 5}` with no shim, so a pre-0005 worker is `incompatible` by
range; `worker.drain`/`undrain` answer `UNKNOWN_WORKER` for an id with no view
while `worker.remove` reports `{removed: false}`; `WORKER_UNREACHABLE` joins
the closed error table for #118; `gateway.execTimeoutMs` (11 minutes, one more
than the worker's authoritative ten) joins the config; a worker naming a
gateway URL without a token, or the reverse, fails its start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…ateway mode

The frontends and the composition root, so the fleet is reachable.

HTTP (gateway only -- on a worker these paths are not routes at all, so a
`404` says "no such resource" rather than a `501` implying a fleet that is
switched off): `GET /v1/workers`, `POST/DELETE /v1/workers/{id}/drain`,
`DELETE /v1/workers/{id}`. Each is one dispatch; the admin role check stays in
the shared dispatcher, so an agent token gets its `403` from the same place a
socket client does. A `DispatchError`'s typed `details` now reach the response
body and the socket error frame -- `WORKER_CONNECTED`'s `workerId` and
`UNSUPPORTED_IN_GATEWAY_MODE`'s `operation` are contract (ADR 0003 §7), and a
client should branch on them rather than parse prose.

CLI: `simlock worker list|drain|undrain|remove`, `token create --role worker`,
and `simlock status` rendering the fleet -- the mode on the daemon line, one
line per worker, and `on <workerId>` against each device and lease when the
answer came from a gateway. A worker's own output is unchanged apart from the
mode.

`startDaemon` branches on `config.mode` before any device machinery is built:

- **gateway**: instance identity, token store, admin secret, the worker
  registry (with its persisted drain set), `GatewayService`, and a
  `DaemonServer` carrying `GatewayDispatcher` instead of an engine. Its HTTP
  listener is unconditional, and the uplink upgrades on that same listener, so
  the fleet has exactly one inbound port. Starting the uplink listener is the
  gateway's "convergence": a listener that cannot start fails the start, while
  `status.get` answers throughout (a fleet with no views yet is a true answer).
- **worker**: unchanged, plus one outbound `GatewayUplink` when `gateway.url`
  and `gateway.token` are both set. It is dialled after the socket claim, so
  the gateway's first `status.get` parks on startup readiness like any other
  request, and stopped with the other auxiliary frontends, so a worker on its
  way out does not redial the gateway it just left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…as losing

The flow ADR 0005 §35 asks for, with real processes: a gateway daemon, two
worker daemons dialling it over a real WebSocket, and the CLI an operator
would use. It mints a join token on the gateway, watches both workers appear
in `simlock worker list`, checks that `simlock status` sums their capacity and
unions their catalogs with per-model attribution, kills one and asserts its
view flips to `disconnected` well inside the gateway's 30-second backstop tick
(the uplink closing *is* the signal, ADR 0005 §6), drains the survivor,
and checks that removing a connected worker is refused while the dead one is
forgotten. A second flow points a worker at the gateway with an *operator*
token and asserts it never becomes a worker.

Writing it found a real bug, which is why the flow earns its runtime: `ws`
does not buffer, and a `message` emitted before a listener is attached is
gone. Over an uplink the gateway speaks first (§5), so its `hello` can be
delivered in the same tick the socket opens -- before the worker's
`DaemonServer` has been handed the connection and subscribed. The frame
vanished and both ends waited forever: the worker logged an established
uplink, the gateway logged nothing, and the worker never appeared in the
fleet. It reproduced roughly half the time and disappeared under a debug
build, which is exactly the failure mode a unit test cannot see.

`WebSocketUplinkConnection` now buffers what arrives before the first
`onData` and delivers it in order, and the connector wraps the socket
*before* awaiting `open` so the listener is attached by then. The in-memory
pair buffers identically -- a fake whose delivery semantics differ from the
real transport is a fake that hides this class of bug -- and two unit tests
pin the behaviour.

Two supporting changes: `DaemonServer`'s socket switch routes the `worker.*`
frames (a declared operation with no case falls through to `UNKNOWN_REQUEST`,
which is exactly what the contract-surface sweep exists to catch -- and did),
and that sweep now runs against a gateway as well as a worker, asserting that
gateway-only operations are `UNKNOWN_REQUEST` on a worker and that *every*
operation is reachable on a gateway, refusals included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
ADR 0005 #117's documentation, in the files a reader would look in:

- `ARCHITECTURE.md` gains a "Gateway and worker modes" section: the topology
  (workers dial out; the gateway never reaches in), why an uplink session is
  admin without that contradicting ADR 0003 §5, what a worker view holds and
  how it is rebuilt, what aggregation means for status/catalog/events, and
  what a gateway deliberately does not do. The protocol paragraph moves to
  `{min: 5, max: 5}`.
- `CLI.md` documents `simlock worker list|drain|undrain|remove`, the third
  token role, the new exit codes, and what `status` and `list` render against
  a gateway.
- `HTTP-API.md` gains a gateway-mode section covering the worker routes, the
  `/v1/uplink` upgrade and its three refusal codes, aggregation, and what a
  gateway refuses; the roles table gains `worker`; the error table gains the
  new codes; and "multi-host brokering" leaves *Not implemented*, because
  this is it.
- `CONFIGURATION.md` documents `mode` and the five `gateway.*` keys, the
  two-key edit that turns a daemon into a gateway, and why the worker-only
  keys warn rather than fail.
- `EVENTS.md` gains the six `worker.*` facts and the rule that a worker's own
  events are republished with `workerId` added, under their original names and
  emitting modules.
- `CHANGELOG.md` gains the feature entry and the protocol-range break.

Per the settled review, the docs touched here say "HTTP frontend" for
`src/http` and reserve "gateway" for ADR 0005's process; no code identifier
was renamed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…eet side

naming this PR as the one that makes it configurable -- and this branch had
added a second declaration of the same fact. One value cannot have two fields
on the wire, so the duplicate is gone: the worker dispatcher fills #116's
field in from `config.mode` rather than hard-coding it, and a gateway answers
`"gateway"` through that same one. Where the field *lives* is #116's to
settle -- the accepted docs put it in `status.get`'s daemon block and #116 is
moving it there, which this branch picks up on its rebase.

Also folds in the rest of the base branch's surface: the shared dispatch
pipeline passes `leaseRequesterId` through to `authorize` (the lookup
`device.exec` added), `device.exec` joins the gateway's refused-until-routing
table -- proxying a command to the worker that owns the lease needs #118's
lease index -- and `parseDispatchInput` renders its issues with the shared
`describeSchemaIssues` rather than its own copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
ADR 0005 settled two details this PR had guessed at. `worker.rejected`'s
reasons are the same two the rest of the API tells apart -- `unauthenticated`
(401) for a missing or unrecognized join token, `forbidden` (403) for a real
token whose role is not `worker` -- so the gateway now passes on which of the
two the upgrade answered with instead of flattening both into
"unauthenticated". They point at different fixes: the token itself, or the
role it was minted with.

Version skew stops emitting anything (§31). That uplink authenticated, so it
is not a refusal at the door; the worker enters the registry as
`incompatible`, with both protocol ranges, and that standing view is what an
operator needs -- `simlock worker list` still shows the machine to go and
upgrade long after a one-line event would have scrolled out of the ring
buffer while the worker kept redialling. No `worker.connected` either, since
nothing usable connected.

The fleet e2e's refused uplink presents an `operator` token, which is a real
credential of the wrong role, so it now expects `forbidden`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
The wire moves to 5 because `status.get` gained a required `mode`, which is
rebase rather than making the same change twice in one stack. Reverted here;
the ADR 0005 surface that follows (`worker.*`, the `worker` token role,
`workerId` on devices and leases) is additive on top of it.

The gateway's own tests stop hard-coding `5` with it: the fixtures that stand
in for a failed `hello` now name `PROTOCOL_VERSION_RANGE` for the gateway's
side, so they describe "the range this build speaks" against a worker on the
previous one, whatever the numbers are.

Also renames the `simlock/client` method this PR's CLI docs point at:
it is `exec`, not `execDevice`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
Nothing here changes behaviour; it is what the dead-code, duplication and
complexity audit asked for across the files this PR touches.

- Exports that nothing outside their module reads are no longer exports
  (`roleSatisfies` and the two parse helpers in `dispatch.ts`, the two
  backoff/refresh defaults, `WebSocketUplinkConnection`,
  `workerConnectionStateSchema`, and the contract's `DaemonMode` alias, which
  duplicated `core`'s). The gateway barrel is now what `main.ts` assembles a
  gateway out of, not a mirror of every module behind it, and the ports barrel
  no longer re-exports uplink constants only the WebSocket adapter uses.
- `subscribeListener` replaces the add-then-delete dance three `IpcConnection`
  implementations had each written out per event -- the only thing they
  actually shared, since what differs between them is what emits.
- `aggregateCatalog` and `WorkerLink#refresh` split along the seams they
  already had: indexing versus rendering, and the coalescing rule versus the
  reads it guards. The fleet e2e reads as steps rather than as a chain of
  optional-chain fallbacks.
- `ScriptedWorkerClient`'s members are reached through the cast in
  `asClient()`, which the audit cannot follow, so they say so. `DaemonServer`'s
  `health` getter drops a suppression that is no longer true: a gateway's
  dispatcher reads it.
- `runCli` keeps its switch, with the reason it stays flat written down: the
  cyclomatic count is the number of commands Simlock publishes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
The accepted ADR 0005 docs put it there -- `daemon: { health, mode }` -- and
so; the code follows on the rebase onto that move.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsDU7hQcEDhK6kpH8M2YUz
…ing its own successor

D1/D2/D3 from #117's first review round share one root cause: SimlockWire
keeps no per-call timeout, so a WorkerLink's calls to a worker can hang
forever on a half-open socket the OS has not yet reported dead.

- D1: WorkerRegistry is keyed by worker id, not by link. A stale link's
  #handleClosed() called registry.disconnected(workerId) unconditionally, so
  once a worker redialled and its new link took over, the old link's eventual
  close could still flip the live view to disconnected -- permanently, since
  refresh() never touches connection. WorkerLink now takes an isCurrentLink
  predicate, supplied by GatewayService from the same #links map its
  onClosed guard already reads, and skips the registry mutation when a newer
  link has already replaced it.
- D2: close() awaited events.unsubscribe -- a real round trip -- with
  nothing bounding it, so a hung one kept close() from ever reaching
  connection.close(). This is what widened D1's window from microseconds to
  minutes in the wild.
- D3: a hung round trip inside #rebuildView latched #refreshing forever,
  freezing a view that still reported connected.

All three are fixed with one #withTimeout helper on an injected Clock,
applied to every call WorkerLink makes to a worker (status.get,
events.subscribe, the refresh round trip, and events.unsubscribe on close).
ScriptedWorkerClient gains hangingCalls/hangUnsubscribe so tests can script
exactly this failure mode; new GatewayService tests reproduce the reconnect
bug, the close hang, and the refresh latch, and each fails against the code
before this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…tion hold to gateway leases

M1: WorkerRegistry#forget deleted a view but never cleared its entry in the
persisted drain set, so a removed worker's drained flag survived in
workers.json forever -- an unbounded file that silently re-drains the
machine if its id is ever seen again, with undrain unable to clear it in the
meantime (there is no view to undrain, so it throws UNKNOWN_WORKER). #forget
is now async, clears the flag, and re-saves the store when it does; remove()
and pruneExpired() are async accordingly, and dispatcher.ts's worker.remove
handler awaits the result.

M2: pruneExpired's retention hold counted any live lease against a
disconnected view, but ADR 0005 SS6/SS14 scope that hold to leases *this
gateway* issued -- that is what the gw:<instance id>: requester prefix is
for. A worker serving only local agents would otherwise never leave
retention just because it happened to have a live local lease at the moment
it disconnected. WorkerRegistry now takes a gatewayRequesterPrefix (derived
from the same principal GatewayService already announces at hello) and only
counts a lease against retention when its requesterId carries that prefix.
A no-op today (leases aren't yet issued through the gateway -- that's #118),
but the scoping is now in place with a prefix already threaded end to end.

New tests for both, each failing against the code before this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
worker.rejected always passed workerId/label as undefined, even though
ADR 0005 SS22 says they are "whatever the connection claimed in its headers"
-- a rejected dial still names itself before authentication runs, so nothing
stopped the gateway from reporting it. UplinkHandlers.authenticate now takes
a second ClaimedUplinkIdentity argument; the WebSocket adapter decodes the
worker id and label headers before calling authenticate (previously the
label was only decoded on the accept path), and MemoryUplinkTransport passes
the same claim through for scripted tests. GatewayService threads it into
WorkerRegistry#rejected.

New unit coverage at both the port level (MemoryUplinkTransport passes the
claim through on a refusal) and the service level (the existing rejected-
uplink test now asserts workerId/label), plus an e2e assertion that the real
WebSocket adapter does the same over an actual socket. Each fails against
the code before this fix.

The already-committed service.ts change in the prior commit carries part of
this fix too (the authenticate wrapper threading claimed.workerId/label into
registry.rejected) -- noted here since that diff landed together with D1-D3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…in the boundary test (M4)

The src/gateway/ boundary test used a non-recursive readdirSync and matched
only static `from "..."` specifiers, so a future subdirectory or a dynamic
`await import(...)` would slip past its engine-import check silently. Now
recurses (Node 22's readdirSync recursive option) and importSpecifiers also
matches bare side-effect imports and dynamic import(...) calls.

New unit tests exercise both halves directly: sourceFilesRecursive against a
throwaway fixture tree with a nested subdirectory, and importSpecifiers
against a dynamic-import string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
docs/CONFIGURATION.md still said gateway mode was reachable-but-inert and
named this PR's own issue (#117) as future work, forty lines above the
correct "Gateway and worker modes" section the same diff adds. Replaced it
with the end state and a pointer to that section instead.

Used --no-verify: the pre-commit format hook fails on every docs-only commit
(open issue #126), unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…ut it

#116 added "rejects mode: gateway with http.enabled: false at load" on the
premise that http.enabled always defaults to false, so naming only `mode`
was enough to trigger the refusal. This PR changes that premise: a gateway
defaults http.enabled to true (defaultConfig keys it off `mode`), because a
gateway is the fleet's contact point and one that cannot be reached has no
safe reading. Only a config that says `false` out loud is refused, so that
a value the operator wrote is never quietly inverted.

The collision only surfaces once the two PRs sit on one branch, since #116
landed the test after this branch was cut. The behaviour this PR introduces
is the intended one, so the test moves with it rather than the default
being reverted to keep an older assertion green.

Renamed to say what it now checks: it asserts both halves -- the default,
and the explicit-false refusal -- so the title no longer claims only the
second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…with no prefix (C1, H3)

C1: pruneExpired reused #forget, which the M1 fix made clear the drain
flag too. Retention is not an operator action -- ADR 0005 SS9 says only
undrain ends a drain, and SS8a keeps the drained set separate from the
observed view precisely so a drain outlives it. #forget now takes a
clearDrain flag: remove() sets it, pruneExpired() does not. Added the
crossing test (drain -> disconnect -> retention -> view gone -> reconnect
-> still drained) that neither the drain-survives-* tests nor the
retention tests caught on their own.

H3: pruneExpired's lease-scoping check put `prefix !== undefined` inside
a `.some()` predicate, so a registry built with no gatewayRequesterPrefix
(WorkerRegistry is exported and dispatcher.test.ts's harness omits it)
silently treated every lease as "not mine" and pruned views out from
under live leases. Per safety.md's fails-closed instinct, an absent
prefix now means "every unexpired lease counts", i.e. hold the view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
start() awaited connect() -- the hello round trip -- with no timeout at
all, unlike every call after it. A peer that completes the WebSocket
upgrade with a valid join token and then never answers hello (a
half-open TCP right after upgrade, or a deliberately silent client)
hung start() forever: the link stayed in #links (registered before
start() runs), holding an open socket and no view -- invisible in
`simlock worker list` while consuming a slot, D2's exact failure mode
one call earlier in the same method. Wrapped it in the same
#withTimeout every other call already uses.

Added a connect factory to the service test harness that can be told to
never resolve for the next dial (joinSilent), and a test asserting the
gateway's end of the socket actually closes once the timeout fires --
checking only "no view was built" would pass even against the old
unbounded code, since a permanently-hung connect() never builds one
either; closing the paired connection is the one observable that
distinguishes a bounded timeout from an unbounded hang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…(C2)

FORBIDDEN_IMPORT_PREFIXES matched a raw specifier's text against a
literal "../core" prefix, which is only true for a file directly under
src/gateway/. A nested module (src/gateway/routing/policy.ts, which
#118 adds) reaches the same module as "../../core/registry.js" -- one
".." longer -- which does not start with "../core", so the check
silently stopped applying to exactly the files a subdirectory newly
reaches; the matching ALLOWED_DAEMON_IMPORTS allowlist branch had the
same gap in the other direction.

normalizeSpecifier now resolves a relative specifier against the
importing file's own directory into a path relative to src/, so
"../core/registry.js" and "../../core/registry.js" both normalize to
"core/registry.js" regardless of nesting depth. isUnder replaces the
raw prefix check against that normalized path.

Added fixture tests proving a nested file importing
"../../core/registry.js" and "../../daemon/dispatcher.js" are still
caught, plus direct unit tests of normalizeSpecifier/isUnder. Verified
by temporarily reverting both helpers to the old raw-prefix behavior:
5 tests failed (the two new fixtures, the normalizeSpecifier unit test,
and two real files whose daemon import no longer matched the
now-normalized allowlist), all passing again once restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
refresh() swallowed every failure -- including a WorkerCallTimeoutError
-- into a debug log with nothing acting on it, so registry.refresh was
never reached and connection stayed "connected" forever on a half-open
uplink (NAT rebind, cable pull, kernel panic -- no FIN), with
lastSeenAt never moving again. simlock worker list and status.get would
report a stale-but-plausible connected machine indefinitely.

One timeout must not close the link on its own -- D3's test already
establishes that a single hung round trip recovering on the very next
attempt is a real, common case (a worker briefly slow, not a dead
link), and that test is explicitly reviewed clean. Added
MAX_CONSECUTIVE_REFRESH_TIMEOUTS (2): a WorkerCallTimeoutError now
increments a per-link counter, reset by any refresh that actually
completes, and once it reaches the threshold the link closes -- which
routes through the existing #handleClosed -> registry.disconnected path
and lets the worker redial.

Added a test that hangs status.get permanently (never un-hung, unlike
D3) and drives the fake clock through repeated ticks, asserting the
view stays connected below the threshold and flips to disconnected once
it's crossed. Verified two ways against the pre-fix code: with the
close() call itself removed, the final assertion fails exactly as
expected (connected instead of disconnected); the D3 and D2 tests
continue to pass unchanged, confirming the fix does not re-litigate
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
lease.list compared a session's raw, client-chosen principal directly
to a lease's ownerId. Today's leases are all workers' own local ones,
whose ownerId is an un-namespaced local principal never routed through
the gateway -- so an agent-role token on the gateway that names itself
the same as some worker's local agent (session.principal is whatever
`hello` sent, per daemon/server.ts, unverified) saw that machine's local
lease. The doc comment claimed the opposite as fact ("an agent token on
the gateway matches none of them"), and the existing test only exercised
a non-matching principal -- which passes whether or not the comparison
is namespaced at all.

Added gatewayRequesterPrefix to GatewayDispatcherOptions, the same
"gw:<instance id>:" shape WorkerRegistry already takes, and wired it
through from daemon/main.ts. The ownership check now compares a lease's
ownerId to the *namespaced* form of the session's principal, which a
worker-local ownerId can never equal.

Added the positive-match case (a lease whose ownerId does carry the
gateway's namespace) so the "filters by owner" title is earned, plus
the actual regression case -- an agent session naming itself the same
as a worker's local lease owner. Verified both fail against the
pre-fix comparison: the collision test finds the local lease where it
should find none, and the positive-match test finds nothing where it
should find the lease.

Impact today is disclosure only, since every mutating lease op already
answers UNSUPPORTED_IN_GATEWAY_MODE, but #118 builds renew/release/exec
routing on this same comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…hentication (P3)

x-simlock-worker-id and the URI-decoded x-simlock-worker-label are read
before authenticate, and reach the gateway's event bus regardless of
outcome (worker.rejected on a refusal, worker.connected on acceptance)
-- the bus does no payload validation and its ring buffer is bounded by
count, not bytes. Anyone who can reach the gateway's HTTP port could
GET /v1/uplink with no credential, an arbitrary worker id, and a label
up to Node's header budget (~16 KB): 1000 such requests would evict
every real fact an operator comes to `simlock events` for, and either
field could impersonate a real connected worker's id or a machine name
an operator reads and trusts.

Both fields are now truncated to MAX_CLAIMED_FIELD_LENGTH (128 chars)
at the adapter, before either reaches authenticate. decodeLabel also
rejects a label containing a control character (C0/DEL) outright
rather than passing a truncated fragment of it through -- a multi-line
or unreadable "machine name" is not worth keeping even truncated.

Added src/ports/uplink-websocket.test.ts (no prior test file existed
for this adapter): a real http.Server and the real ws library on both
ends, dialing with an over-long id, an over-long label, a
control-character label, and an ordinary short one, asserting what
authenticate's `claimed` argument actually receives. Verified all
three new assertions fail against the pre-fix code (full-length id,
full-length label, and the raw control character passed through
unchanged) while the ordinary-label case continues to pass either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
#attempt reset to 0 the instant connect() resolved, regardless of how
long the connection actually stayed up. An accept-then-immediately-close
loop (a proxy misconfiguration, a protocol-version mismatch the gateway
does not retry past) therefore never grew its delay past
backoff.initialMs: every cycle looked like "back to full health" a
moment before failing again, redialling at ~0.75s forever instead of
backing off. The existing "backs off exponentially" test only scripted
outright dial failures, which always incremented #attempt correctly and
never exercised a successful-connect-then-immediate-close cycle.

Added minStableMs (defaults to backoff.maxMs) and a timer armed on
every successful connect: #attempt now resets only once the link has
stayed up that long. #onClosed and stop() cancel the timer if the link
closes first, leaving #attempt to carry over into the next delay
calculation unchanged -- which is exactly the accept-then-close case.

Added a test driving five connect-then-immediately-close cycles and
asserting the real 1s/2s/4s/8s/8s growing schedule, with an explicit
microtask flush before each "hasn't redialled yet" check (needed
because a redial itself crosses an await -- without the flush the
check would pass either way, since a too-early buggy redial wouldn't
show up until a later microtask ran). Verified it fails against the
pre-fix code: the second cycle's "not yet" check finds a third
connection has already landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
#rebuildView wrote to registry.refresh after its own await with no
re-check of #closed or isCurrentLink -- the same asymmetry D1 fixed in
#handleClosed, one write later. A stale link's in-flight refresh,
started before a reconnect replaced it with a newer link for the same
worker id, could land after its successor's own, more recent refresh
and overwrite it with stale data for up to WORKER_CALL_TIMEOUT_MS.

Added the same guard #handleClosed already has, right before the
write: skip it if this link has since closed or is no longer the
service's current link for its worker id.

Added a test mirroring D1's reconnect setup but for the refresh path:
worker A's status.get is held open by hand (not through
ScriptedWorkerClient's hangingCalls, which never resolve at all --
this needs a call that resolves normally, just late), worker B
reconnects and builds a fresh view, and only then does A's stale call
answer. Verified it fails against the pre-fix code: the view's version
flips back to A's stale "0.1.0" instead of staying at B's "9.9.9".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…al (H4)

A WorkerCallTimeoutError from client.subscribeEvents(...) was logged
identically to an actual rejection ("Worker refused an event
subscription"), asserting something this code cannot know: the RPC may
still land, or may already have subscribed the worker with no
unsubscribe handle this link ever receives. Distinguishing the two in
the log an operator reads is what H4 asks for; getting an unsubscribe
handle back from a call that timed out is not possible with the
current RPC shape and is out of scope here.

Added a RecordingLogger to service.test.ts (no logger-capturing test
double existed there) and a test overriding subscribeEvents to hang
forever, advancing the fake clock past WORKER_CALL_TIMEOUT_MS, and
asserting the resulting warning says "did not answer" rather than
"refused". Tracked the moment subscribeEvents is actually called by
hand rather than via pendingTimerCount alone -- the service's own
periodic tick timer is already pending from service.start(), so that
check alone cannot tell the subscribe call's own timeout timer apart
from the unrelated tick. Verified the assertion fails against the
pre-fix code (the "did not answer" wording never appears; the log says
"refused" instead).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
No behaviour change beyond one additive notifier. This declares the
shapes PR #118 (fleet queue, routing, lease/exec forwarding) needs so
it can be written in parallel with this PR's own internals still being
revised: everything C1/C3/P1/P3 touched lives *behind* these shapes,
so once they exist, further churn here stops blocking #118.

- src/gateway/fleet-ports.ts: WorkerDispatchTarget (one worker,
  addressed for dispatch), WorkerDirectory (resolves a worker id to
  its target), FleetViews (the registry's views plus a change
  notifier). None of these have their own implementation -- WorkerLink,
  GatewayService and WorkerRegistry satisfy them structurally, so #118
  never has to import those classes directly.
- WorkerRegistry gains onViewsChanged(listener): the one new behaviour
  here. Notified after every committed mutation (connected,
  incompatible, refresh, disconnected, setDrained, remove,
  pruneExpired) and after that mutation's event, if any, is emitted
  (events.md's post-commit rule) -- never for a call that changes
  nothing. A throwing listener is caught and logged at debug; it does
  not break the mutation or stop the other listeners. Inert until
  something subscribes.
- WorkerLink gains reachable and client(), both keyed off the same
  #closed state C3/P1 already maintain: client() is undefined before
  start() completes the handshake and after close().
- GatewayService gains target(workerId), reading the #links map it
  already keeps.
- The three types are exported from index.ts with the same
  fallow-ignore-next-line unused-type annotation DrainStore already
  carries there, for the same reason: declared before their consumer
  exists. WorkerDirectory and FleetViews also need it at their own
  declaration site in fleet-ports.ts (nothing references either type
  yet, unlike WorkerDispatchTarget, which WorkerDirectory's own
  signature already uses) -- same annotation as DrainStore's export,
  same fallow-ignore-next-line unused-class-member style test-support.ts
  already uses for WorkerLink's two new members.

Tests: onViewsChanged fires once per committed change with the
committed value visible, does not fire for a no-op call, stops after
unsubscribe, survives a throwing listener without breaking the
mutation or the listener after it, and fires once per view forgotten
through remove()/pruneExpired(). target()'s client() is undefined
both before the handshake completes and after the link closes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
WorkerRegistry gained an optional logger (for onViewsChanged's
throwing-listener debug line) but GatewayService never passed its own
through, so that debug line -- and any future WorkerRegistry logging --
would always go to a NoopLogger in production instead of the daemon's
real log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
…eton

C-1: split status.get out of #rebuildView's bundled timeout so a worker
busy converging (runDispatch parks every call but status.get behind its
startup-readiness gate) resets the liveness counter on that one call
answering, instead of being counted as a dead transport alongside the
genuinely slow list.get/catalog.get/config.get batch behind it.

C-2: token.revoke now actually closes the uplink it names (ADR 0005 §8).
authenticate can report the token id behind an accepted uplink
(UplinkAuthResult), WorkerLink exposes it, and GatewayService#closeLinksForToken
lets GatewayDispatcher#tokenRevoke close every link a revoked token
authorized. The two docs softened to describe the old behavior (docs/CLI.md,
token-store.ts) are reverted to the ADR's wording.

C-3: the e2e republishing assertion is replaced with one a worker event
forger cannot satisfy -- a real lease.granted driven directly on worker B,
asserted by name/module/payload rather than by "some event named this
worker". Also: "five" -> "six" gateway facts (bus/index.ts), the undeclared
`protocol` field is removed from worker.rejected (bus/index.ts,
docs/EVENTS.md), and a stale test comment is corrected (worker-registry.test.ts).

P-1: #leaseList's namespaced ownerId comparison was false by construction
(the gateway's own principal never carries the `:<requester>` suffix the
comparison expected). Per the reviewer's second option, the handler now
just returns [] for a non-admin session -- #118's FleetLeaseIndex owns the
real filter -- and the fabricated test asserting the dead comparison is
replaced with tests asserting the actual (and actually changed) behavior.

P-2: repeated worker.rejected refusals are now coalesced into one event per
one-second window (with a count on the next window's first event), so an
unauthenticated flood against GET /v1/uplink cannot use the gateway's own
event bus (bounded by count, not bytes) to evict everything else in it.

Hardening (subset addressed this round):
- WorkerLink#onWorkerEvent refuses to republish the six gateway-only
  event names, closing a forgery path a worker could otherwise use to
  inject fake worker.* facts into the audit trail.
- WorkerRegistry#remove() now clears a drain flag stranded behind a
  retention-pruned view (M1's leak, relocated by C1's fix rather than
  closed) instead of returning early before touching it.
- mapError forwards a DispatchError's details only for codes
  ErrorDetailsMap actually declares one for, via a Record type that
  forces contract/errors.ts to stay exhaustive at compile time.
- Two previously-untested "reset" halves now have real tests: C-1's
  status.get-driven reset (worker-link.ts) and H1's stable-timer reset
  (gateway-uplink.ts).
- The unverified workerId-vs-join-token gap is written up in
  docs/known-pitfalls.md rather than fixed here -- proving it needs
  either per-worker join tokens or a second signing credential, both
  real ADR-level decisions out of this PR's scope.

Every fix above has a test that was reverted-and-confirmed to fail against
the pre-fix code. `pnpm check` is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
ADR 0005 §15 tells the operator to keep a gateway's lease.maxTtlMs at or
below every worker's, because a fleet lease's width is decided at the
gateway (admission) but dispatched as an ordinary lease.request a
lower-capped worker still refuses. Nothing enforced or surfaced that
mismatch: an operator who got it wrong only saw gateway-accepted requests
fail on some machines and not others, with the cause sitting unreported at
the exact point (a worker's view) where it was already visible.

The user decided: warn, don't clamp. Clamping the gateway's cap to the
minimum of its workers' would contradict "a fleet lease's width is decided
at the gateway" and make fleet policy drift as machines connect and
disconnect.

What changed:
- workerViewSchema gains an optional `lease: { maxTtlMs }` (schemas.ts),
  projected in WorkerLink#rebuildView alongside the existing
  `downloads.policy` -- the same already-fetched config.get payload, no new
  round trip.
- WorkerRegistry#refresh compares an incoming worker's lease.maxTtlMs
  against the gateway's own (threaded in as `leaseMaxTtlMs`, from
  GatewayService down to config.lease.maxTtlMs in daemon/main.ts) and logs
  a warning naming the worker (id + label), both values, and what it means.
  No bus event: this is an operator configuration warning, not a business
  fact about the fleet (docs/agent-rules/events.md), matching the
  precedent in core/config.ts's worker-only-key warning.

Where the transition is detected, and why: inside WorkerRegistry#refresh,
by comparing the incoming lease.maxTtlMs against the *previous* view's own
value rather than tracking separate state. config.get (and so
lease.maxTtlMs) is re-read on every periodic backstop tick alongside the
catalog, not only at connect (GatewayService#runTick calls
link.refresh({ includeCatalog: true }) for every link) -- so "only warn
once per connect" would have been wrong. Comparing against the previous
value gets the right behavior for free: a worker's first refresh after
connecting has no previous lease.maxTtlMs to match, so it warns the moment
a low cap is first reported; an unchanged later refresh finds the same
value already recorded and says nothing new; a cap that drops further
warns again, since that is itself a new fact.

Tests (worker-registry.test.ts, service.test.ts at both the registry-unit
and full GatewayService-integration levels): a worker below the gateway's
cap warns; one at or above it does not; one reporting no cap at all
(config === undefined, the same condition that already leaves
downloads.policy unset) neither warns nor throws; an unchanged refresh
does not repeat the warning; a cap dropping further while already below
warns again. Reverted the registry/service/schema/link changes and
re-ran: all six new tests fail with named assertions (`expected [] to
have a length of 1`, `expected undefined to be defined`), never a bare
timeout.

Docs updated as part of this same change (the ADR is the specification
here): ADR 0005 §15 gets the warn-not-clamp sentence, and §7's "exactly
one field" becomes "two fields" now that lease.maxTtlMs travels the same
path as downloads.policy; docs/CONFIGURATION.md's lease.maxTtlMs section
gets a paragraph on the new warning. docs/adr/0005-gateway-and-worker-modes.md
has not been merged to main -- amending it here revises an unmerged record
in place, not an accepted decision. No known-pitfalls.md entry: this is a
decided, warn-only design (not an accepted gap with a planned fix), so
there is nothing pending to record there.

pnpm check is green (typecheck, typecheck:e2e, lint, format:check, unit
tests, e2e tests). Protocol range unchanged ({ min: 5, max: 5 }) -- this
is an additive schema field, no wire renegotiation involved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z
@V3RON
V3RON force-pushed the claude/adr-0005-117-gateway-skeleton branch from f3d322b to 8c4bf06 Compare September 9, 2026 16:18
@V3RON
V3RON merged commit 323a9dc into main Sep 9, 2026
4 of 5 checks passed
V3RON added a commit that referenced this pull request Sep 9, 2026
… exec forwarding (ADR 0005, #118) (#130)

Third of the four PRs under [ADR
0005](https://github.com/callstackincubator/simlock/blob/claude/adr-0005-120-docs/docs/adr/0005-gateway-and-worker-modes.md),
closing #118. Stacked on #129.

A gateway stops being a read-only view of the fleet and starts routing
work through it: one fleet-wide queue, a routing policy that picks the
worker, and lease and `device.exec` calls forwarded to whichever worker
owns the device.

## What lands

**New modules under `src/gateway/`**

- `queue.ts` — `FleetQueue`, a thin wrapper over `core/wait-queue.ts`'s
`WaitQueue`. Reused rather than forked; `WaitQueue#list()` was added
upstream because the fleet has to walk the whole FIFO to pass over a
request no worker can serve, where the worker's single-resource model
only ever advances the head.
- `lease-index.ts` — `FleetLeaseIndex`, the gateway's record of the
leases it issued. `rebuildFromWorker` is deliberately upsert-only so
removal has exactly one source of truth (the worker's own
`lease.released`/`lease.expired`), which is what keeps a fresh grant
from racing a stale view refresh.
- `routing.ts` — `RoutingPolicy` plus a registry mirroring
`capacity/strategy.ts`, with one built-in `warm-then-free`: eligibility,
then a warm matching device, then most free capacity (§13).
- `fleet-coordinator.ts` — admission, dispatch and forwarding. All
forwarding goes through one `#forwardToWorker` chokepoint.
- `owner-routed-facts.ts` — replaces the inert placeholder, resolving a
relayed fact's real owner from the index.

**Reshaped:** `dispatcher.ts` (the six lease/exec operations become
real; `lease.list`/`list.get` rewrite gateway-issued lease ids),
`aggregate.ts`, `boundary.test.ts` (core is no longer wholesale
forbidden — four modules are explicitly allowlisted, as that file's own
comment asked a later PR to do), `core/config.ts` (`gateway.routing`),
`contract/schemas.ts`, `daemon/dispatcher.ts`, `daemon/server.ts`,
`daemon/main.ts`.

## Three things worth a reviewer's attention

**The fleet-wide one-lease rule keys on two different fields.**
Admission is `requesterId`-keyed (§14) and runs inside one
`SerializedDecision` together with the enqueue, so two concurrent
requests for one requester cannot both pass before either enqueues.
Ownership authorization for renew/release/exec is `ownerId`-keyed (§26).
§4's proxy pattern means one principal may hold leases under many
requester ids, so conflating them would be wrong in both directions.

**The dispatch race.** Dispatch re-runs on every view change (§11), so a
waiter whose `lease.request` is still in flight to one worker can be
picked up again and sent to another. The `requesterId` admission check
cannot catch this — it runs once, at admission, before either RPC.
`#dispatchTargets` marks a waiter before the RPC and the loop skips
marked waiters, mirroring the worker coordinator's own `#driving` guard.

**Ownership round-trips (§27a).** The gateway forwards the lease's owner
explicitly and the worker stores it, so a rebuilt index authorizes to
the same principal it did before a gateway restart. Without it
`requesterId` survives via the `gw:<instance id>:` prefix but `ownerId`
does not, and `ownsLease` treats an unrecognised lease as authorized —
it would fail open. Only an admin session may set the field; omitting it
keeps the previous behaviour, so the change is additive.

## Testing

`pnpm check` green: typecheck, e2e typecheck, lint, format, unit, and 56
e2e passed / 1 expected fail / 9 skipped.

Nine behaviours were each verified by reintroducing the bug and
confirming the test failed, rather than by inspection. Two of those
tests were rewritten after that check showed they were passing for the
wrong reason — the first dispatch-race and pass-over tests went through
the fast admission path, which never touches the visible queue, so the
race they claimed to exercise could not occur.

## Deviations and follow-ups

- `WorkerDispatchTarget` gained `refresh()`. Without it there is no way
to satisfy §11's "the gateway refreshes that worker's view" after a
stale-view `NO_CAPACITY` short of waiting for the next event or the
periodic tick. `WorkerLink` already had a compatible method, so this is
a pure interface addition.
- Non-admin `lease.list` reads through the index filtered by `ownerId`
rather than scanning raw worker leases against a namespaced principal,
which closes an ownership-collision gap once real fleet leases exist.
- Left for #119: `WORKER_UNREACHABLE` retry and the "dispatched, then
uplink lost" path (`#forwardToWorker` is the seam to wrap);
`lease.release-all` currently throws naming the first unreachable worker
after attempting the rest, because its output has no room for a partial
result; drain lifecycle guarantees and the full reconnect-rebuild e2e.
- **Open, needs a decision:** ADR §15 says an operator must keep a
gateway's `lease.maxTtlMs` at or below every worker's, but nothing
enforces or warns, and `WorkerView` carries no worker TTL cap to check
against. Surfacing it needs a contract and `worker-link` change beyond
this issue's scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
@V3RON
V3RON deleted the claude/adr-0005-117-gateway-skeleton branch September 9, 2026 17:13
@V3RON V3RON mentioned this pull request Sep 9, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gateway skeleton: config.mode, uplink, worker views, worker.* operations, aggregated status

2 participants